iT邦幫忙

2025 iThome 鐵人賽

DAY 22
0

前言

歡迎來到 Day 22!看到這篇文章的同時,表示中秋連假也到此為止了,但如果你是個狠人請了下週的三天假,那恭喜你!假期才剛開始呢:D OK! 幹話結束,我們昨天在 Supabase 上開啟了驗證系統以及使用者練習紀錄的資料表,雖然目前兩者都是完全沒有使用到的,除了主控台頁面上空空的欄位之外我們什麼也沒看到,今天就要利用昨天的鋪墊做整合,在我們專案中加入 Supabse 後端 Auth 的邏輯。

你可能會感到有點奇怪,「我們明明用 Supabase 已經好一段時間了,邏輯也都寫在後端,甚至還整理出個 lib/supabase.ts 檔案抽分邏輯,你怎麼還會說我們要做 Supabase 的後端整合?」。這個問題問得很好,我們必須先釐清這一點!直接說結論,在整合 Supabase Auth 時,我們需要兩種不同職責的 Supabase Client。

一個是我們已有的、使用最高權限 SERVICE_KEY 的「後端管理員 Client」,專門用來執行像 RAG 搜尋這樣的系統級任務。另一個則是我們今天要建立的、使用公開 ANON_KEY 的「前端使用者 Client」,專門用來處理登入、註冊等與使用者身份相關的操作。

理解了這個區別後,我們將採用 Supabase 最新的 @supabase/ssr 工具庫,以最現代、最安全的方式來打造我們的登入與註冊頁面。

今日目標

  • 安裝並設定 Supabase 最新的 @supabase/ssr 工具庫。
  • 清晰劃分職責:將現有的後端 Client 重新歸位,並建立一個新的前端專用 Client。
  • 打造一個包含 Email/密碼輸入框及註冊/登入按鈕的 UI 元件。
  • 實作呼叫 Supabase Auth 進行註冊與登入的核心邏輯。
  • 在儀表板頁面加入登出按鈕,完成身份驗證的閉環。

Step 1: 安裝 Supabase SSR 工具庫

首先,我們需要安裝最新的 @supabase/ssr 套件,它將取代已被棄用的 auth-helpers,如果你之前沒有跟著教學,那你得記得還要額外安裝@supabase/supabase-js

npm install @supabase/ssr

Step 2: 重構並建立 Client 檔案

為了讓架構清晰,我們在 lib 下建立一個 supabase 資料夾,並分別管理前後端的 Client。

重構後端 Server Client

將你現有的 lib/supabase.ts 移動並重命名為 lib/supabase/server.ts,記得透過IDE或手動的方式去修正引入的地方。它的原本內容則保持不變,因為它作為後端管理員的角色是正確的,我們只要新增幾個部分就好。

// app/lib/supabase/server.ts

import { createClient } from '@supabase/supabase-js';
import { createServerClient } from '@supabase/ssr'// 新增引入
import { cookies } from 'next/headers'; // 新增引入

// ...原本的rag相關東西
// 創建一個用於認證的 Supabase client
export async function createAuthClient() {
  const cookieStore = await cookies();
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll();
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          } catch {
            // The `setAll` method was called from a Server Component.
            // This can be ignored if you have middleware refreshing
            // user sessions.
          }
        },
      },
    }
  );
}


建立前端 Browser Client

現在,我們使用 @supabase/ssr 來建立一個專門給前端元件使用的 Client。

// app/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr';

export function createClient() {
  // 建立一個專門給瀏覽器環境 (Client Components) 使用的 Supabase Client
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
  );
}

裡面出現了一個我們之前沒有建立的環境變數,請你回到專案中的.env.local檔案,並加入這個NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,值的部分從專案的Project Settings -> API Keys 進入管理 Key 頁面,再次抱怨一下,介面更新但 AI 資料沒跟上時真的會造成一堆鬼打牆,我的 AI 助理死命給我錯的資訊,請按照下圖找到指定的 Key。

圖1
圖1 :PUBLISHABLE_KEY位置

另外在我們忘記之前,也記得把這個東西存到你Vercel專案的環境變數,免得部署時又要再抱怨一次。

Step 3: 設定 Middleware

Middleware 是 Next.js 中一個強大的功能,它會在請求完成前執行。對於 Supabase Auth 來說,它的職責是自動刷新即將過期的 Token,確保使用者在瀏覽網站時的登入狀態始終保持最新,並保護需要登入才能訪問的頁面。
官方推薦將邏輯拆分為兩個檔案,以保持程式碼的整潔和可維護性。

1. 建立 Middleware 輔助函式

這個檔案包含了與 Supabase 互動、刷新 Session、以及處理重新導向的核心邏輯。
請先建立一個新的檔案在這個檔案路徑: app/utils/supabase/middleware.ts

// app/utils/supabase/middleware.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function updateSession(request: NextRequest) {
  let response = NextResponse.next({
    request: {
      headers: request.headers,
    },
  })

  const supabase = createServerClient(
    process.env.SUPABASE_URL!,
    process.env.SUPABASE_PUBLISHABLE_KEY!,
    {
      cookies: {
        get(name: string) {
          return request.cookies.get(name)?.value
        },
        set(name: string, value: string, options: CookieOptions) {
          // If the cookie is set, update the request cookies and response cookies
          request.cookies.set({ name, value, ...options })
          response = NextResponse.next({
            request: {
              headers: request.headers,
            },
          })
          response.cookies.set({ name, value, ...options })
        },
        remove(name: string, options: CookieOptions) {
          // If the cookie is removed, update the request cookies and response cookies
          request.cookies.set({ name, value: '', ...options })
          response = NextResponse.next({
            request: {
              headers: request.headers,
            },
          })
          response.cookies.set({ name, value: '', ...options })
        },
      },
    }
  )

  // 刷新使用者 session,這至關重要!
  // 它能確保即使 token 過期,也能在下一次請求時自動刷新。
  // 官方特別強調,不要移除這一行!
  const { data: { user } } = await supabase.auth.getUser()

  // 如果使用者未登入,且訪問的不是登入相關頁面,則將其重新導向至登入頁
  if (
    !user &&
    !request.nextUrl.pathname.startsWith('/login') &&
    !request.nextUrl.pathname.startsWith('/auth') // 排除 /auth/callback 等路由
  ) {
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    return NextResponse.redirect(url)
  }


  // 必須回傳這個 response 物件,它包含了更新後的 cookie。
  // 如果不這樣做,瀏覽器和伺服器之間的 session 會不同步,導致使用者被意外登出。
  return response
}

2. 建立根目錄的 Middleware 進入點

這個檔案非常簡潔,它的唯一工作就是引入並執行我們剛剛建立的 updateSession 函式。

// middleware.ts
import { updateSession } from '@/app/utils/supabase/middleware'
import { type NextRequest } from 'next/server'

export async function middleware(request: NextRequest) {
  return await updateSession(request)
}

export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - _next/static (static files)
     * - _next/image (image optimization files)
     * - favicon.ico (favicon file)
     * Feel free to modify this pattern to include more paths.
     */
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}

你問到有點看不懂,這很正常,Middleware 裡的邏輯確實比較抽象。讓我把它拆解成白話文:想像一下你的網站是一棟有門禁的大樓。

middleware.ts (大樓警衛)

這位警衛(middleware.ts)不會自己做太多判斷。他的工作手冊上只有一條指令:「所有進來的人(除了送貨員 - matcher 裡排除的靜態檔案),都交給保全中心(updateSession 函式)處理。」

updateSession 函式 (保全中心)

這是真正的核心。當警衛把一個訪客(request)帶進來時,保全中心會做以下幾件事:

  • A. 準備好溝通工具:它首先建立一個能在伺服器上讀寫訪客通行證(Cookies)的 Supabase Client。
  • B. 檢查通行證:它呼叫 supabase.auth.getUser()。這一步極其重要,它會拿著訪客的通行證(Cookie)去 Supabase 總部進行遠端驗證。如果通行證快過期了,總部會發一張新的回來,保全中心會立刻幫訪客更新。
  • C. 判斷訪客身份與目的地:
    • if (!user ...) 這段邏輯是在說:「如果經過總部驗證後,發現這個人根本沒有合法的通行證(!user),而且他要去的地方不是一樓大廳或訪客登記處(!/login, !/auth),那他肯定走錯了!」
  • D. 執行門禁管制:一旦發現上述情況,保全中心會立刻把他引導回一樓大廳(return NextResponse.redirect('/auth')),不讓他去其他樓層。
  • E. 放行:如果訪客有合法的通行證,或者他本來就是要去大廳,保全中心就會蓋個章(更新後的 Cookie 會被放在 response 物件裡),然後讓他繼續前往他原本要去的目的地 (return response)。

Step 4: 打造登入/註冊 UI 與邏輯

基礎設置做好了,來處理登入跟註冊的邏輯吧,為了示範的方便,我大幅度簡化了 UI 並省略了基本的輸入驗證,只是單純整合官方的教學而已,實際上你在做的時候需要加入一些前端認證,我這邊為了示範方便直接把它弄成一個 server頁面處理,請你先新增app/auth資料夾並在裡面放入以下三個檔案。

// app/auth/action.ts
'use server';

import { createAuthClient } from '@/app/lib/supabase/server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { headers } from 'next/headers';

export async function login(formData: FormData) {
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;
  const supabase = await createAuthClient();

  const { error } = await supabase.auth.signInWithPassword({
    email,
    password,
  });

  if (error) {
    const message = '登入失敗,請檢查您的帳號或密碼';
    return redirect(`/auth?message=${encodeURIComponent(message)}`);
  }

  revalidatePath('/', 'layout');
  return redirect('/dashboard');
}

export async function signup(formData: FormData) {
  const origin = (await headers()).get('origin');
  const email = formData.get('email') as string;
  const password = formData.get('password') as string;
  const supabase = await createAuthClient();

  const { error } = await supabase.auth.signUp({
    email,
    password,
    options: {
      emailRedirectTo: `${origin}/auth/callback`,
    },
  });

  if (error) {
    const message = '註冊失敗,該信箱可能已被使用';
    return redirect(`/auth?message=${encodeURIComponent(message)}`);
  }

  revalidatePath('/', 'layout');
  const message = '註冊成功!請檢查您的信箱以完成驗證';
  return redirect(`/auth?message=${encodeURIComponent(message)}`);
}



另一個則是 UI 的頁面

// app/auth/page.tsx
import { login, signup } from '@/app/auth/action';
import { Bot, Mail, Lock } from 'lucide-react';

export default function AuthPage({
  searchParams,
}: {
  searchParams: { message: string };
}) {
  return (
    <div className="min-h-screen bg-gradient-to-br from-gray-900 via-blue-900 to-gray-900 flex items-center justify-center p-4 font-sans">
      <div className="w-full max-w-md">
        <div className="text-center mb-8">
          <div className="flex justify-center mb-4">
            <Bot size={48} className="text-blue-400" />
          </div>
          <h1 className="text-3xl font-bold text-white mb-2">
            AI Interview Pro
          </h1>
          <p className="text-gray-400">精進技能,成為頂尖工程師</p>
        </div>

        <div className="bg-gray-800 bg-opacity-75 backdrop-blur-sm rounded-2xl p-8 shadow-2xl border border-gray-700">
          <form className="space-y-6">
            <div className="space-y-4">
              <div>
                <label
                  className="block text-sm font-medium text-gray-300 mb-2"
                  htmlFor="email"
                >
                  電子郵件
                </label>
                <div className="relative">
                  <span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
                    <Mail size={20} />
                  </span>
                  <input
                    id="email"
                    name="email"
                    type="email"
                    className="w-full bg-gray-700 text-white rounded-lg pl-10 pr-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500"
                    placeholder="example@email.com"
                    required
                  />
                </div>
              </div>

              <div>
                <label
                  className="block text-sm font-medium text-gray-300 mb-2"
                  htmlFor="password"
                >
                  密碼
                </label>
                <div className="relative">
                  <span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400">
                    <Lock size={20} />
                  </span>
                  <input
                    id="password"
                    name="password"
                    type="password"
                    className="w-full bg-gray-700 text-white rounded-lg pl-10 pr-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500"
                    placeholder="輸入密碼"
                    required
                  />
                </div>
              </div>
            </div>

            {searchParams?.message && (
              <p className="text-center text-sm text-green-400 p-2 bg-green-900/30 rounded-md">
                {searchParams.message}
              </p>
            )}

            <div className="flex flex-col sm:flex-row gap-2">
              <button
                formAction={login}
                className="flex-1 w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-3 rounded-lg transition-transform transform hover:scale-105 duration-300"
              >
                登入
              </button>
              <button
                formAction={signup}
                className="flex-1 w-full bg-gray-600 hover:bg-gray-700 text-white font-bold py-3 rounded-lg transition-transform transform hover:scale-105 duration-300"
              >
                註冊
              </button>
            </div>
          </form>
        </div>
      </div>
    </div>
  );
}

最後一個檔案則是用來處理點擊信箱收到的認證按鈕後,要跳轉頁面的邏輯。

// app/auth/callback/route.ts
import { createAuthClient } from '@/app/lib/supabase/server';
import { NextResponse } from 'next/server';

export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url);
  const code = searchParams.get('code');
  const next = searchParams.get('next') ?? '/dashboard';

  if (code) {
    const supabase = await createAuthClient();
    const { error } = await supabase.auth.exchangeCodeForSession(code);

    if (!error) {
      // 驗證成功,重定向到 dashboard
      return NextResponse.redirect(`${origin}${next}`);
    }
  }

  // 如果有錯誤,重定向回登入頁面
  return NextResponse.redirect(
    `${origin}/auth?message=${encodeURIComponent('驗證失敗,請重試')}`
  );
}

隨便輸入一組信箱與密碼,按下註冊按鈕,你應該會看到註冊成功的畫面。

圖2
圖2 :註冊成功畫面

若你輸入的真正的信箱,在本地開發環境中點擊按鈕後你應該會發現跳轉失敗,這是因為你需要在 Supabase 的設置中加入頁面跳轉的邏輯處理,我們這邊以本地開發示範。

請到 Authentication → URL Configuration 中,作以下兩個步驟。

圖3
圖3 :跳轉設定頁面

Step 6: 加入登出功能

最後一個步驟,修改我們的側邊導覽去增加登出的按鈕。

'use client';
import {
  BotMessageSquare,
  LayoutDashboard,
  MessageSquare,
  Code,
  History,
  LogOut,
} from 'lucide-react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { createClient } from '@/app/lib/supabase/client'; // <-- 關鍵:引入前端 Client

const navItems = [
  { href: '/dashboard', label: '主控台', icon: LayoutDashboard },
  { href: '/practice/concept', label: '概念問答', icon: MessageSquare },
  { href: '/practice/code', label: '程式實作', icon: Code },
  { href: '/history', label: '練習紀錄', icon: History },
  // { href: '/settings', label: '設定', icon: Settings }, // 設定頁面尚未實作
];

export default function Sidebar() {
  const pathname = usePathname();
  const router = useRouter();

  const isActive = (href: string) => {
    if (href === '/dashboard') return pathname === href;
    return pathname.startsWith(href);
  };

  // --- 新增:登出處理函式 ---
  const handleLogout = async () => {
    // 1. 建立一個前端專用的 Supabase client
    const supabase = createClient();

    // 2. 呼叫 signOut 方法
    const { error } = await supabase.auth.signOut();

    if (error) {
      console.error('登出時發生錯誤:', error);
      alert('登出失敗,請稍後再試。');
    } else {
      // 3. 登出成功後,將使用者導向回登入頁面
      //    並重新整理以確保伺服器狀態更新
      router.push('/auth');
      router.refresh();
    }
  };
  // --- 結束新增 ---

  return (
    <nav className="hidden md:flex w-64 bg-[#111827] text-gray-300 flex-col p-4 border-r border-gray-700">
      <div className="flex items-center gap-3 mb-8">
        <BotMessageSquare size={32} className="text-blue-400" />
        <h1 className="text-xl font-bold">AI Interview Pro</h1>
      </div>
      <ul className="space-y-2">
        {navItems.map(({ href, label, icon: Icon }) => (
          <li key={label}>
            <Link
              href={href}
              className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
                isActive(href)
                  ? 'bg-blue-600/30 text-white'
                  : 'hover:bg-gray-700'
              }`}
            >
              <Icon size={20} /> {label}
            </Link>
          </li>
        ))}
      </ul>
      <div className="mt-auto">
        {' '}
        {/* <--- 新增:將登出按鈕推至底部 */}
        <button
          onClick={handleLogout}
          className="w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-colors text-gray-400 hover:bg-red-900/50 hover:text-red-300"
        >
          <LogOut size={20} /> 登出
        </button>
      </div>
    </nav>
  );
}

點下去後你就會發現已經順利登出了,查看 Devtool -> Applications -> Cookies 你會發現有個sb開頭的key消失,證明你的 seesion 已經結束了。

今日回顧

今天,我們不僅成功地為應用程式裝上了現代化的大門,更重要的是,我們建立了一個清晰、安全的 Supabase Client 架構。

✅ 我們採用了最新的 @supabase/ssr 工具庫,確保驗證機制與 Next.js App Router 完美整合。
✅ 我們清晰地劃分了後端 Server Client 和前端 Browser Client 的職責,讓專案架構更穩固。
✅ 我們打造了功能齊全的登入與註冊 UI 頁面。
✅ 我們成功實作了使用者的註冊、登入與登出邏輯。

明日預告

使用者現在可以真正登入我們的系統了!下一步,就是要讓他們的練習成果能夠被永久保存。明天 (Day 23),我們將修改後端的 /api/interview/evaluate API,讓它能夠識別出當前登入的使用者,並在 AI 評估完成後,將這筆寶貴的練習紀錄,連同使用者的 ID,一起安全地寫入 practice_records 資料表中!

今日程式碼: https://github.com/windate3411/Itiron-2025-code/tree/day-22


上一篇
再訪 Supabase:建立使用者系統與資料庫基石
下一篇
留下學習的足跡 - 將 AI 評估結果寫入練習紀錄
系列文
前端工程師的AI應用開發實戰:30天從Prompt到Production - 以打造AI前端面試官為例23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言